This analysis examines whether changes in federal spending between FY2020 and FY2024:
The analysis is observational and evaluates correlation, not causation.
Did Spending Under Biden reflect voting patterns either in Presidential or House Races, and is there a correlation between that spending and the results in 2024?
We compare federal obligations per capita across states using a Biden-era average (FY2021–FY2024) versus a pre-Biden baseline (FY2017–FY2020), then test how those changes align with 2024 presidential and House margins.
library(tidyverse)
library(janitor)
library(lubridate)
library(httr)
library(jsonlite)
library(readxl)
library(scales)
library(ggrepel)
library(usmap)
This document uses a single low-ink-to-data style guide for consistent, uncluttered charts.
library(ggplot2)
library(scales)
if (!requireNamespace("bbplot", quietly = TRUE)) {
if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes")
remotes::install_github("bbc/bbplot")
}
library(bbplot)
# Strict low-ink theme: no gridlines, no axis lines, small labels
theme_low_ink <- function(base_size = 11) {
bbplot::bbc_style() %+replace% theme(
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor.y = element_blank(),
axis.line = element_blank(),
axis.ticks = element_blank(),
axis.text.y = element_text(size = base_size * 0.6),
axis.text.x = element_text(size = base_size * 0.7),
legend.title = element_blank(),
legend.position = "none",
plot.title.position = "plot",
plot.title = element_text(margin = margin(b = 6)),
plot.subtitle = element_text(margin = margin(b = 8))
)
}
theme_set(theme_low_ink())
# Party color scale (muted, low-ink)
scale_party_fill <- function(...) {
scale_fill_manual(
values = c(
"Biden" = "#2166AC", # muted Dem blue
"Trump" = "#B2182B" # muted GOP red
),
...
)
}
label_billions <- label_dollar(scale = 1e-9, suffix = "B", accuracy = 0.1)
label_dollars1 <- label_dollar(accuracy = 1)
label_pct1 <- label_percent(accuracy = 1)
usaspending_post <- function(endpoint, body) {
res <- POST(
url = paste0("https://api.usaspending.gov", endpoint),
body = body,
encode = "json",
add_headers(`Content-Type` = "application/json")
)
stop_for_status(res)
content(res, as = "parsed", simplifyVector = TRUE)
}
get_state_obligations <- function(fy) {
body <- list(
scope = "place_of_performance",
geo_layer = "state",
filters = list(
time_period = list(list(
start_date = paste0(fy - 1, "-10-01"),
end_date = paste0(fy, "-09-30")
))
)
)
out <- usaspending_post("/api/v2/search/spending_by_geography/", body)
# Robustly normalize the results payload. Depending on httr/content parsing,
# `out$results` may be a data.frame/tibble OR a list of records.
res <- out$results
if (is.null(res) || length(res) == 0) {
return(tibble(state = character(), fiscal_year = integer(), obligations = numeric()))
}
if (is.data.frame(res)) {
return(tibble(
state = toupper(res$shape_code),
fiscal_year = fy,
obligations = res$aggregated_amount
))
}
tibble(
state = toupper(purrr::map_chr(res, "shape_code")),
fiscal_year = fy,
obligations = purrr::map_dbl(res, "aggregated_amount")
)
}
library(tidyverse)
library(janitor)
library(scales)
# Compare baseline vs FY2024 using alpha (no patterns)
# Ensure population table exists (created in the population chunk). If knitting from the middle,
# we'll load it from disk to avoid execution-order issues.
if (!exists("pop")) {
if (file.exists("population_by_state_fy.csv")) {
pop <- readr::read_csv("population_by_state_fy.csv", show_col_types = FALSE) |>
janitor::clean_names() |>
dplyr::transmute(
state = toupper(state),
fiscal_year = as.integer(fiscal_year),
pop = as.numeric(pop)
)
message("Loaded pop from population_by_state_fy.csv")
# Ensure pres2020 exists (created in pres2020 chunk). If knitting from the middle,
# we'll derive it from local election files.
if (!exists("pres2020")) {
if (file.exists("president_2020.csv")) {
pres_long <- readr::read_csv("president_2020.csv", show_col_types = FALSE) |>
janitor::clean_names() |>
dplyr::filter(year == 2020) |>
dplyr::mutate(state = toupper(state_po))
} else if (file.exists("1976-2020-president.csv")) {
pres_long <- readr::read_csv("1976-2020-president.csv", show_col_types = FALSE) |>
janitor::clean_names() |>
dplyr::filter(year == 2020) |>
dplyr::mutate(state = toupper(state_po))
} else {
stop("pres2020 not found and no election file present. Add president_2020.csv (preferred) or 1976-2020-president.csv, or knit from the top.")
}
pres2020 <- pres_long |>
dplyr::group_by(state) |>
dplyr::summarize(
votes_biden = sum(candidatevotes[stringr::str_detect(toupper(candidate), "BIDEN")], na.rm = TRUE),
votes_trump = sum(candidatevotes[stringr::str_detect(toupper(candidate), "TRUMP")], na.rm = TRUE),
biden_share_2p = votes_biden / (votes_biden + votes_trump),
biden_won = votes_biden > votes_trump,
.groups = "drop"
) |>
dplyr::mutate(
winner_2020 = dplyr::if_else(biden_won, "Biden", "Trump"),
winner_2020 = factor(winner_2020, levels = c("Biden", "Trump"))
)
message("Derived pres2020 from local election file.")
}
} else {
stop("Population table `pop` not found. Knit from the top (Run All), or ensure the population chunk runs first to create population_by_state_fy.csv.")
}
}
# Pull spending for FY2017–FY2024 (needed for Pre vs Biden baseline and FY2024 levels)
spending <- purrr::map_dfr(2017:2024, get_state_obligations)
# Sanity check to avoid blank charts
spend_check <- spending |>
count(fiscal_year, name = "n_states") |>
arrange(fiscal_year)
print(spend_check)
## # A tibble: 8 × 2
## fiscal_year n_states
## <int> <int>
## 1 2017 57
## 2 2018 57
## 3 2019 57
## 4 2020 57
## 5 2021 57
## 6 2022 57
## 7 2023 57
## 8 2024 57
if (nrow(spending) == 0) stop("USAspending returned 0 rows. Check connectivity/API response.")
if (any(spend_check$n_states < 40)) warning("Some years returned fewer than 40 states; plots may be incomplete.")
# Merge population for per-capita measures (pop is created in the population chunk)
if (!exists("pop")) stop("Population table `pop` not found. Ensure the population chunk runs before this chunk.")
spending_pc <- spending |>
left_join(pop, by = c("state","fiscal_year")) |>
mutate(oblig_pc = obligations / pop)
# Pre-Biden baseline (FY17–FY20 avg) and Biden era (FY21–FY24 avg)
spend_era <- spending_pc |>
mutate(
era = case_when(
fiscal_year %in% 2017:2020 ~ "Pre (FY17–FY20 avg)",
fiscal_year %in% 2021:2024 ~ "Biden (FY21–FY24 avg)",
TRUE ~ NA_character_
)
) |>
filter(!is.na(era)) |>
group_by(state, era) |>
summarize(
total = mean(obligations, na.rm = TRUE),
pc = mean(oblig_pc, na.rm = TRUE),
.groups = "drop"
) |>
pivot_wider(names_from = era, values_from = c(total, pc)) |>
rename(
pre_total = `total_Pre (FY17–FY20 avg)`,
biden_total = `total_Biden (FY21–FY24 avg)`,
pre_pc = `pc_Pre (FY17–FY20 avg)`,
biden_pc = `pc_Biden (FY21–FY24 avg)`
) |>
mutate(
delta_biden_vs_pre_total = biden_total - pre_total,
delta_biden_vs_pre_pc = biden_pc - pre_pc
)
# FY2024 levels (solid bars)
fy2024 <- spending_pc |>
filter(fiscal_year == 2024) |>
select(state, total_2024 = obligations, pc_2024 = oblig_pc)
# Bar chart data: baseline (striped) vs FY2024 (solid), colored by 2020 winner
if (!exists("pres2020")) stop("pres2020 not found. Ensure the pres2020 chunk runs before the bar charts.")
bars_total <- fy2024 |>
select(state, value = total_2024) |>
mutate(series = "FY2024") |>
bind_rows(
spend_era |>
select(state, value = pre_total) |>
mutate(series = "Pre (FY17–FY20 avg)")
) |>
left_join(pres2020 |> select(state, winner_2020), by = "state") |>
mutate(series = factor(series, levels = c("Pre (FY17–FY20 avg)", "FY2024")))
bars_pc <- fy2024 |>
select(state, value = pc_2024) |>
mutate(series = "FY2024") |>
bind_rows(
spend_era |>
select(state, value = pre_pc) |>
mutate(series = "Pre (FY17–FY20 avg)")
) |>
left_join(pres2020 |> select(state, winner_2020), by = "state") |>
mutate(series = factor(series, levels = c("Pre (FY17–FY20 avg)", "FY2024")))
# Section 12.1 delta dataset
delta_12_1 <- spend_era |>
left_join(pres2020 |> select(state, winner_2020), by = "state")
Provide a CSV named population_by_state_fy.csv with
columns:
statefiscal_yearpoplibrary(tidyverse)
library(janitor)
library(readr)
library(stringr)
# Build accurate state populations for FY2017–FY2024 using Census Population Estimates:
# - 2010–2019 series for 2017–2019
# - 2020–2024 series for 2020–2024
#
# If the source CSVs are not present, this chunk will download them from Census.
POP_OUT <- "population_by_state_fy.csv"
CENSUS_2010S_FILE <- "NST-EST2019-ALLDATA.csv"
CENSUS_2020S_FILE <- "NST-EST2024-ALLDATA.csv"
CENSUS_2010S_URL <- "https://www2.census.gov/programs-surveys/popest/datasets/2010-2019/national/totals/nst-est2019-alldata.csv"
CENSUS_2020S_URL <- "https://www2.census.gov/programs-surveys/popest/datasets/2020-2024/state/totals/NST-EST2024-ALLDATA.csv"
# If you've already created POP_OUT, we use it for speed + reproducibility.
if (file.exists(POP_OUT)) {
pop <- read_csv(POP_OUT, show_col_types = FALSE) |>
clean_names() |>
transmute(
state = toupper(state),
fiscal_year = as.integer(fiscal_year),
pop = as.numeric(pop)
)
} else {
# Download Census source files if missing
if (!file.exists(CENSUS_2010S_FILE)) {
download.file(CENSUS_2010S_URL, destfile = CENSUS_2010S_FILE, mode = "wb", quiet = TRUE)
}
if (!file.exists(CENSUS_2020S_FILE)) {
download.file(CENSUS_2020S_URL, destfile = CENSUS_2020S_FILE, mode = "wb", quiet = TRUE)
}
pop_2010s_raw <- read_csv(CENSUS_2010S_FILE, show_col_types = FALSE) |> clean_names()
pop_2020s_raw <- read_csv(CENSUS_2020S_FILE, show_col_types = FALSE) |> clean_names()
# Keep only state-level rows: SUMLEV == 40 (states), plus DC (also SUMLEV 40 in these files)
# Puerto Rico is included; keep it if you want, but our later charts focus on states + DC.
pop_2010s <- pop_2010s_raw |>
filter(sumlev == 40) |>
select(name, starts_with("popestimate")) |>
mutate(name = toupper(name))
pop_2020s <- pop_2020s_raw |>
filter(sumlev == 40) |>
select(name, starts_with("popestimate")) |>
mutate(name = toupper(name))
# State name -> abbreviation (50 states + DC)
state_lu <- tibble(
name = toupper(state.name),
state = state.abb
) |>
add_row(name = "DISTRICT OF COLUMBIA", state = "DC")
# 2017–2019 from 2010s file; 2020–2024 from 2020s file
pop_2017_2019 <- pop_2010s |>
inner_join(state_lu, by = "name") |>
pivot_longer(
cols = matches("^popestimate(2017|2018|2019)$"),
names_to = "year",
values_to = "pop"
) |>
mutate(
fiscal_year = as.integer(str_extract(year, "\\d{4}")),
pop = as.numeric(pop)
) |>
select(state, fiscal_year, pop)
pop_2020_2024 <- pop_2020s |>
inner_join(state_lu, by = "name") |>
pivot_longer(
cols = matches("^popestimate(2020|2021|2022|2023|2024)$"),
names_to = "year",
values_to = "pop"
) |>
mutate(
fiscal_year = as.integer(str_extract(year, "\\d{4}")),
pop = as.numeric(pop)
) |>
select(state, fiscal_year, pop)
pop <- bind_rows(pop_2017_2019, pop_2020_2024) |>
arrange(state, fiscal_year)
# Save a tidy, analysis-ready file for your zip bundle
write_csv(pop, POP_OUT)
message("Created ", POP_OUT, " using Census sources: ", CENSUS_2010S_FILE, " and ", CENSUS_2020S_FILE)
}
# Quick check: should cover FY2017–FY2024 for 51 entities (50 states + DC)
pop_check <- pop |>
filter(fiscal_year %in% 2017:2024) |>
count(fiscal_year, name = "n_states") |>
arrange(fiscal_year)
print(pop_check)
## # A tibble: 8 × 2
## fiscal_year n_states
## <int> <int>
## 1 2017 51
## 2 2018 51
## 3 2019 51
## 4 2020 51
## 5 2021 51
## 6 2022 51
## 7 2023 51
## 8 2024 51
# OPTION 1: Per-capita delta defined as FY2024 − FY2020 (states + DC only)
valid_states <- c(state.abb, "DC")
# For election-correlation charts, restrict to states + DC
spending_pc_states <- spending_pc |>
mutate(state = toupper(state)) |>
filter(state %in% valid_states)
spending_delta <- spending_pc_states |>
filter(fiscal_year %in% c(2020, 2024)) |>
select(state, fiscal_year, obligations, oblig_pc) |>
pivot_wider(
names_from = fiscal_year,
values_from = c(obligations, oblig_pc),
names_prefix = "fy"
) |>
mutate(
delta_pc = oblig_pc_fy2024 - oblig_pc_fy2020,
delta_total = obligations_fy2024 - obligations_fy2020
)
# Sanity check: should be ~51 rows and delta_pc should be non-missing
print(spending_delta |>
summarize(n_rows = n(), n_delta_pc = sum(!is.na(delta_pc)), n_delta_total = sum(!is.na(delta_total))))
## # A tibble: 1 × 3
## n_rows n_delta_pc n_delta_total
## <int> <int> <int>
## 1 51 51 51
Bars are colored by whether the state’s electoral
votes went to Biden or Trump
in 2020.
Provide a CSV named president_2020.csv in this folder (MIT
Election Lab export recommended) with at least:
yearstate_po (two-letter postal abbreviation)candidatecandidatevotesPRES2020_FILE <- "president_2020.csv"
if (!file.exists(PRES2020_FILE)) {
stop("Missing file: president_2020.csv. Put it in the same folder as this Rmd to color bars by Biden/Trump in 2020.")
}
pres_long <- read_csv(PRES2020_FILE, show_col_types = FALSE) |>
clean_names() |>
filter(year == 2020) |>
mutate(state = toupper(state_po))
pres2020 <- pres_long |>
group_by(state) |>
summarize(
votes_biden = sum(candidatevotes[str_detect(toupper(candidate), "BIDEN")], na.rm = TRUE),
votes_trump = sum(candidatevotes[str_detect(toupper(candidate), "TRUMP")], na.rm = TRUE),
biden_share_2p = votes_biden / (votes_biden + votes_trump),
biden_won = votes_biden > votes_trump,
.groups = "drop"
) |>
mutate(
winner_2020 = if_else(biden_won, "Biden", "Trump"),
winner_2020 = factor(winner_2020, levels = c("Biden", "Trump"))
)
These charts use a horizontal bar layout for readable state labels, and follow the low-ink theme set above.
# Ensure consistent series ordering within each state: Pre above FY2024
pre_lab <- "Pre (FY17–FY20 avg)"
fy24_lab <- "FY2024"
# Order states by the maximum of the two series for readability
state_order_total <- bars_total |>
group_by(state) |>
summarize(max_val = max(value, na.rm = TRUE), .groups = "drop") |>
arrange(max_val) |>
pull(state)
# Force y-axis levels: for each state, Pre then FY2024
y_levels_total <- c(rbind(
paste0(state_order_total, " ", pre_lab),
paste0(state_order_total, " ", fy24_lab)
))
bars_total_plot <- bars_total |>
mutate(
series = factor(series, levels = c(pre_lab, fy24_lab)),
alpha_series = if_else(series == pre_lab, 0.5, 1.0),
state_series = paste0(state, " ", as.character(series)),
state_series = factor(state_series, levels = y_levels_total)
)
ggplot(bars_total_plot, aes(x = value, y = state_series, fill = winner_2020, alpha = alpha_series)) +
geom_col(width = 0.75) +
scale_party_fill() +
scale_alpha_identity(guide = "none") +
scale_y_discrete(labels = function(x) {
st <- sub(" .*", "", x)
ifelse(duplicated(st), "", st)
}) +
scale_x_continuous(labels = label_billions) +
labs(
title = "Federal obligations by state: FY2024 vs pre-Biden baseline",
subtitle = "Top bar per state = mean(FY2017–FY2020); bottom bar = FY2024",
x = "Obligations (billions of $)",
y = NULL
) +
theme_low_ink() +
theme(
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
axis.line = element_blank(),
axis.ticks = element_blank(),
axis.text.y = element_text(size = 7)
)
Why data beyond the IIJA snapshot: The IIJA spreadsheet is a program‑specific funding snapshot, but the research question requires total federal obligations across all programs. The USAspending API provides full‑scope spending needed to compare eras and align with election outcomes.
Comparable time windows: Using FY2017–FY2020 vs FY2024 creates a stable baseline instead of a single year or a single program, making changes more interpretable.
# Ensure consistent series ordering within each state: Pre above FY2024
pre_lab <- "Pre (FY17–FY20 avg)"
fy24_lab <- "FY2024"
# Order states by the maximum of the two series for readability
state_order_pc <- bars_pc |>
group_by(state) |>
summarize(max_val = max(value, na.rm = TRUE), .groups = "drop") |>
arrange(max_val) |>
pull(state)
# Force y-axis levels: for each state, Pre then FY2024
y_levels_pc <- c(rbind(
paste0(state_order_pc, " ", pre_lab),
paste0(state_order_pc, " ", fy24_lab)
))
bars_pc_plot <- bars_pc |>
mutate(
series = factor(series, levels = c(pre_lab, fy24_lab)),
alpha_series = if_else(series == pre_lab, 0.5, 1.0),
state_series = paste0(state, " ", as.character(series)),
state_series = factor(state_series, levels = y_levels_pc)
)
ggplot(bars_pc_plot, aes(x = value, y = state_series, fill = winner_2020, alpha = alpha_series)) +
geom_col(width = 0.75) +
scale_party_fill() +
scale_alpha_identity(guide = "none") +
scale_y_discrete(labels = function(x) {
st <- sub(" .*", "", x)
ifelse(duplicated(st), "", st)
}) +
scale_x_continuous(labels = label_dollars1) +
labs(
title = "Federal obligations per capita by state: FY2024 vs pre-Biden baseline",
subtitle = "Top bar per state = mean(FY2017–FY2020); bottom bar = FY2024",
x = "Obligations per capita ($)",
y = NULL
) +
theme_low_ink() +
theme(
panel.grid.major = element_blank(),
panel.grid.minor = element_blank(),
axis.line = element_blank(),
axis.ticks = element_blank(),
axis.text.y = element_text(size = 7)
)
Why per‑capita is clearer: Per‑capita spending controls for population size, revealing states that look small in total dollars but large per resident.
Per‑capita pitfalls: Very small population states and DC can show extreme values because the denominator is small. These outliers are real but can exaggerate swings, so they should be read alongside totals.
states_2024 <- read_csv("https://michaelminn.net/tutorials/data/2024-electoral-states.csv") |> clean_names()
districts_2024 <- read_csv("https://michaelminn.net/tutorials/data/2024-electoral-districts.csv") |> clean_names()
pres_2024 <- states_2024 |>
transmute(
state = st,
pres_margin_dem = (votes_dem_2024 - votes_gop_2024) /
(votes_dem_2024 + votes_gop_2024)
)
house_2024 <- districts_2024 |>
group_by(st) |>
summarize(
house_margin_dem =
(sum(votes_dem_2024) - sum(votes_gop_2024)) /
(sum(votes_dem_2024) + sum(votes_gop_2024)),
.groups = "drop"
) |>
rename(state = st)
elections_2024 <- left_join(pres_2024, house_2024, by = "state")
analysis <- delta_12_1 |>
select(state, delta_biden_vs_pre_pc) |>
left_join(elections_2024, by = "state")
pres_plot_data <- analysis |>
filter(!is.na(pres_margin_dem))
pres_lm <- lm(pres_margin_dem ~ delta_biden_vs_pre_pc, data = pres_plot_data)
pres_r2 <- summary(pres_lm)$r.squared
pres_p <- summary(pres_lm)$coefficients[2, 4]
pres_label <- sprintf("R2 = %.3f\np = %.3g", pres_r2, pres_p)
ggplot(pres_plot_data, aes(delta_biden_vs_pre_pc, pres_margin_dem)) +
geom_hline(yintercept = 0, linewidth = 0.25) +
geom_vline(xintercept = 0, linewidth = 0.25) +
geom_point() +
geom_smooth(method = "lm", se = FALSE) +
annotate(
"label",
x = Inf, y = Inf,
label = pres_label,
hjust = 1.1, vjust = 1.1,
size = 4,
label.size = 0.25,
fill = "white",
color = "black"
) +
scale_x_continuous(labels = dollar) +
scale_y_continuous(labels = percent) +
labs(
title = "Change in Federal Spending (Biden Era vs Pre-Biden) vs 2024 Presidential Margin",
x = "Δ Federal Obligations per Capita (mean FY2021–FY2024 − mean FY2017–FY2020)",
y = "2024 Presidential Margin (Dem − GOP)"
)
What This Chart Shows:
This scatter plot examines whether average federal spending per capita in the Biden era (FY2021–FY2024) versus the pre-Biden baseline (FY2017–FY2020) correlates with how states voted in the 2024 presidential election. Each point represents one state (or DC), with:
Moderate Positive Association: The fitted line is upward sloping, with a correlation of about 0.49 and an R2 ~ 0.24. States with larger per-capita spending increases tended to have more Democratic 2024 presidential margins, though the relationship is far from deterministic.
All States Show Positive Spending Change: Because this compares two multi-year averages, every state has a positive delta. That means all points fall in the positive-spending half, split between Dem margins (20 states) and GOP margins (31 states). The partisan split underscores that spending increases did not uniformly translate into electoral support.
Notable Outliers: Several states sit far from the trend line, suggesting other forces dominate:
house_plot_data <- analysis |>
filter(!is.na(house_margin_dem))
house_lm <- lm(house_margin_dem ~ delta_biden_vs_pre_pc, data = house_plot_data)
house_r2 <- summary(house_lm)$r.squared
house_p <- summary(house_lm)$coefficients[2, 4]
house_label <- sprintf("R2 = %.3f\np = %.3g", house_r2, house_p)
ggplot(house_plot_data, aes(delta_biden_vs_pre_pc, house_margin_dem)) +
geom_hline(yintercept = 0, linewidth = 0.25) +
geom_vline(xintercept = 0, linewidth = 0.25) +
geom_point() +
geom_smooth(method = "lm", se = FALSE) +
annotate(
"label",
x = Inf, y = Inf,
label = house_label,
hjust = 1.1, vjust = 1.1,
size = 4,
label.size = 0.25,
fill = "white",
color = "black"
) +
scale_x_continuous(labels = dollar) +
scale_y_continuous(labels = percent) +
labs(
title = "Change in Federal Spending (Biden Era vs Pre-Biden) vs 2024 House Margin",
x = "Δ Federal Obligations per Capita (mean FY2021–FY2024 − mean FY2017–FY2020)",
y = "2024 House Margin (Dem − GOP)"
)
What This Chart Shows:
This scatter plot tests whether federal spending changes correlate with aggregate House election outcomes at the state level. The analysis uses:
Moderate Positive Association: The House margin relationship is somewhat stronger, with a correlation of about 0.57 and R2 ~ 0.32. States with larger spending increases generally show more Democratic House margins, though there is still substantial dispersion.
District Aggregation Still Blurs Signal: House results are the sum of many district contests. A state’s aggregate margin can mask district‑level swings that are unrelated to statewide spending changes.
Outliers Highlight Local Dynamics: States with large residuals include HI, VT, WY, MD, and SD, indicating that local political factors can overwhelm any spending-related pattern.
Correlation, Not Causation: As with the presidential plot, this is an observational association. The chart does not establish whether spending affects votes or whether both reflect deeper structural factors.
delta_plot <- delta_12_1 |>
mutate(delta = delta_biden_vs_pre_pc) |>
arrange(delta) |>
mutate(state = factor(state, levels = state))
ggplot(delta_plot, aes(x = delta, y = state, fill = winner_2020)) +
geom_col(width = 0.75) +
scale_party_fill(guide = "none") +
scale_x_continuous(labels = label_dollars1) +
labs(
title = "Change in obligations per capita: Biden era vs pre-Biden baseline",
subtitle = "Δ = mean(FY2021–FY2024) − mean(FY2017–FY2020)",
x = "Δ obligations per capita ($)",
y = NULL
) +
theme_low_ink()
What This Chart Shows:
This bar chart compares average federal obligations per capita during the Biden era (FY2021–FY2024) against the pre-Biden baseline (FY2017–FY2020). Bars extending right indicate increased spending; bars extending left indicate decreased spending. Colors represent which candidate won each state in 2020 (blue = Biden, red = Trump).
All States Show Increases vs Pre-Biden Baseline: Every state (and DC) has a positive per-capita change. The smallest increase is roughly +$569 (AL), while the largest is about +$27,032 (DC).
Largest Increases Concentrated in a Few States: The biggest per-capita gains are in DC (+$27.0k), MN (+$14.4k), KY (+$11.1k), CT (+$9.2k), and AK (+$7.0k). Small-population jurisdictions amplify per-capita effects, which helps explain DC’s extreme value.
Smallest Increases Cluster Near the Baseline: The lowest increases are in AL (+$569), WI (+$751), IA (+$1,572), GA (+$1,594), and TN (+$1,595), indicating relatively flat change compared to other states.
Balanced Partisan Mix Among Increases: The distribution of gains is split almost evenly: 26 Biden-won states and 25 Trump-won states show increases, indicating no simple partisan allocation pattern at the state level.
Extremes Are Not Exclusively Partisan: Large increases appear in both Biden-won (DC, MN, CT) and Trump-won (KY, AK) states, reinforcing that sectoral and programmatic factors likely dominate over electoral alignment.
Challenges Simple Narratives: This chart provides evidence against claims that the Biden administration systematically “rewarded” states that voted Democratic in 2020: the data show no clear partisan pattern.
Complex Causality: The wide variation across states with different political alignments suggests federal spending patterns result from complex interactions of policy priorities, economic needs, programmatic structures, and institutional factors: not simple political calculations.
Observational Analysis: These findings represent observed correlations between spending changes and political characteristics. Causation cannot be inferred: we cannot determine whether political factors influenced spending, whether spending influenced politics, or whether both were driven by other factors.
You have two recommended options:
Use officer + rvg to insert charts as
editable vectors:
library(officer)
library(rvg)
ppt <- read_pptx()
ppt <- add_slide(ppt, layout = "Title and Content", master = "Office Theme")
ppt <- ph_with(
ppt,
dml(ggobj = last_plot()),
location = ph_location_type(type = "body")
)
print(ppt, target = "spending_elections_2020_2024.pptx")
This preserves full resolution and allows text editing directly in PowerPoint.
This page lists local data files used in the analysis, their original sources, and a brief summary of how each was processed.
Summary Answer
Spending increases in the Biden era are broad-based across states, but the scatter plots show only moderate positive associations with 2024 presidential and House margins. The relationship is real but not deterministic: states with similar spending changes often voted differently, and outliers remain. In short, spending shifts alone do not explain 2024 election outcomes, though they move in the same direction on average.
USAspending (federal obligations by state,
FY2017–FY2024)
Local file: Not stored locally (pulled live via API during
analysis)
Source: https://api.usaspending.gov/api/v2/search/spending_by_geography/
Processing: API responses are normalized to state abbreviations, then
combined with Census population to compute per-capita obligations and
multi-year averages.
Census population estimates (2010s series)
Local file: NST-EST2019-ALLDATA.csv
Source: https://www2.census.gov/programs-surveys/popest/datasets/2010-2019/national/totals/nst-est2019-alldata.csv
Processing: Filtered to SUMLEV 40 (states + DC), then reshaped to long
format for 2017–2019 and mapped to state abbreviations.
Census population estimates (2020s series)
Local file: NST-EST2024-ALLDATA.csv
Source: https://www2.census.gov/programs-surveys/popest/datasets/2020-2024/state/totals/NST-EST2024-ALLDATA.csv
Processing: Filtered to SUMLEV 40 (states + DC), then reshaped to long
format for 2020–2024 and mapped to state abbreviations. Combined with
2010s series to build population_by_state_fy.csv.
Combined population by state and fiscal year
Local file: population_by_state_fy.csv
Source: Derived from the two Census files above
Processing: Union of 2017–2024 state populations with standardized
state, fiscal_year, and pop
fields used for per-capita metrics.
2024 presidential results (state level)
Local file: 2024-electoral-states.csv
Source: https://michaelminn.net/tutorials/data/2024-electoral-states.csv
Processing: Cleaned column names and converted to a Democratic margin:
(Dem votes − GOP votes) / (Dem + GOP).
2024 House results (district level)
Local file: 2024-electoral-districts.csv
Source: https://michaelminn.net/tutorials/data/2024-electoral-districts.csv
Processing: Aggregated by state to compute a statewide House margin
using total Dem and GOP votes.
2020 presidential results (for 2020 winner
coloring)
Local file: president_2020.csv
Source: User-provided (original source not embedded in the file)
Processing: Filtered to 2020 and the two major candidates, then assigned
a state winner based on total votes per candidate.